Files
OpenClaw-bot-review/lib/pixel-office/layout/layoutSerializer.ts
T

532 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TileType, FurnitureType, DEFAULT_COLS, DEFAULT_ROWS, TILE_SIZE, Direction } from '../types'
import type { TileType as TileTypeVal, OfficeLayout, PlacedFurniture, Seat, FurnitureInstance, FloorColor } from '../types'
import { getCatalogEntry } from './furnitureCatalog'
import { isWalkable } from './tileMap'
import { getColorizedSprite } from '../colorize'
/** Convert flat tile array from layout into 2D grid */
export function layoutToTileMap(layout: OfficeLayout): TileTypeVal[][] {
const map: TileTypeVal[][] = []
for (let r = 0; r < layout.rows; r++) {
const row: TileTypeVal[] = []
for (let c = 0; c < layout.cols; c++) {
row.push(layout.tiles[r * layout.cols + c])
}
map.push(row)
}
return map
}
/** Convert placed furniture into renderable FurnitureInstance[] */
export function layoutToFurnitureInstances(furniture: PlacedFurniture[]): FurnitureInstance[] {
// Pre-compute desk zY per tile so surface items can sort in front of desks
const deskZByTile = new Map<string, number>()
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry || !entry.isDesk) continue
const deskZY = item.row * TILE_SIZE + entry.sprite.length
for (let dr = 0; dr < entry.footprintH; dr++) {
for (let dc = 0; dc < entry.footprintW; dc++) {
const key = `${item.col + dc},${item.row + dr}`
const prev = deskZByTile.get(key)
if (prev === undefined || deskZY > prev) deskZByTile.set(key, deskZY)
}
}
}
const instances: FurnitureInstance[] = []
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry) continue
const x = item.col * TILE_SIZE
const y = item.row * TILE_SIZE
const spriteH = entry.sprite.length
let zY = y + spriteH
// Chair z-sorting: ensure characters sitting on chairs render correctly
if (entry.category === 'chairs') {
if (entry.orientation === 'back') {
// Back-facing chairs render IN FRONT of the seated character
// (the chair back visually occludes the character behind it)
zY = (item.row + 1) * TILE_SIZE + 1
} else {
// All other chairs: cap zY to first row bottom so characters
// at any seat tile render in front of the chair
zY = (item.row + 1) * TILE_SIZE
}
}
// Surface items render in front of the desk they sit on
if (entry.canPlaceOnSurfaces) {
for (let dr = 0; dr < entry.footprintH; dr++) {
for (let dc = 0; dc < entry.footprintW; dc++) {
const key = `${Math.round(item.col + dc)},${Math.round(item.row + dr)}`
const deskZ = deskZByTile.get(key)
if (deskZ !== undefined && deskZ + 0.5 > zY) zY = deskZ + 0.5
}
}
}
let sprite = entry.sprite
if (item.color) {
const { h, s, b: bv, c: cv } = item.color
sprite = getColorizedSprite(`furn-${item.type}-${h}-${s}-${bv}-${cv}-${item.color.colorize ? 1 : 0}`, entry.sprite, item.color)
}
instances.push({
uid: item.uid,
sprite,
x,
y,
zY,
...(entry.emoji ? { emoji: entry.emoji } : {}),
...(item.rotation ? { rotation: item.rotation } : {}),
...(entry.emojiScale ? { emojiScale: entry.emojiScale } : {}),
})
}
return instances
}
/** Get all tiles blocked by furniture footprints, optionally excluding a set of tiles.
* Skips top backgroundTiles rows so characters can walk through them. */
export function getBlockedTiles(furniture: PlacedFurniture[], excludeTiles?: Set<string>): Set<string> {
const tiles = new Set<string>()
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry) continue
const bgRows = entry.backgroundTiles || 0
for (let dr = 0; dr < entry.footprintH; dr++) {
if (dr < bgRows) continue // skip background rows — characters can walk through
for (let dc = 0; dc < entry.footprintW; dc++) {
const key = `${item.col + dc},${item.row + dr}`
if (excludeTiles && excludeTiles.has(key)) continue
tiles.add(key)
}
}
}
return tiles
}
/** Get tiles blocked for placement purposes — skips top backgroundTiles rows per item */
export function getPlacementBlockedTiles(furniture: PlacedFurniture[], excludeUid?: string): Set<string> {
const tiles = new Set<string>()
for (const item of furniture) {
if (item.uid === excludeUid) continue
const entry = getCatalogEntry(item.type)
if (!entry) continue
const bgRows = entry.backgroundTiles || 0
for (let dr = 0; dr < entry.footprintH; dr++) {
if (dr < bgRows) continue // skip background rows
for (let dc = 0; dc < entry.footprintW; dc++) {
tiles.add(`${item.col + dc},${item.row + dr}`)
}
}
}
return tiles
}
/** Map chair orientation to character facing direction */
function orientationToFacing(orientation: string): Direction {
switch (orientation) {
case 'front': return Direction.DOWN
case 'back': return Direction.UP
case 'left': return Direction.LEFT
case 'right': return Direction.RIGHT
default: return Direction.DOWN
}
}
/** Generate seats from chair furniture.
* Facing priority: 1) chair orientation, 2) adjacent desk, 3) forward (DOWN). */
export function layoutToSeats(furniture: PlacedFurniture[]): Map<string, Seat> {
const seats = new Map<string, Seat>()
// Build set of all desk tiles
const deskTiles = new Set<string>()
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry || !entry.isDesk) continue
for (let dr = 0; dr < entry.footprintH; dr++) {
for (let dc = 0; dc < entry.footprintW; dc++) {
deskTiles.add(`${item.col + dc},${item.row + dr}`)
}
}
}
const dirs: Array<{ dc: number; dr: number; facing: Direction }> = [
{ dc: 0, dr: -1, facing: Direction.UP }, // desk is above chair → face UP
{ dc: 0, dr: 1, facing: Direction.DOWN }, // desk is below chair → face DOWN
{ dc: -1, dr: 0, facing: Direction.LEFT }, // desk is left of chair → face LEFT
{ dc: 1, dr: 0, facing: Direction.RIGHT }, // desk is right of chair → face RIGHT
]
// For each chair, every footprint tile becomes a seat.
// Multi-tile chairs (e.g. 2-tile couches) produce multiple seats.
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry || entry.category !== 'chairs') continue
let seatCount = 0
for (let dr = 0; dr < entry.footprintH; dr++) {
for (let dc = 0; dc < entry.footprintW; dc++) {
const tileCol = item.col + dc
const tileRow = item.row + dr
// Determine facing direction:
// 1) Chair orientation takes priority
// 2) Adjacent desk direction (use rounded coords for grid lookup)
// 3) Default forward (DOWN)
let facingDir: Direction = Direction.DOWN
const roundedCol = Math.round(tileCol)
const roundedRow = Math.round(tileRow)
if (item.uid.startsWith('stool-r')) {
facingDir = Direction.LEFT
} else if (entry.orientation) {
facingDir = orientationToFacing(entry.orientation)
} else {
for (const d of dirs) {
if (deskTiles.has(`${roundedCol + d.dc},${roundedRow + d.dr}`)) {
facingDir = d.facing
break
}
}
}
// First seat uses chair uid (backward compat), subsequent use uid:N
const seatUid = seatCount === 0 ? item.uid : `${item.uid}:${seatCount}`
seats.set(seatUid, {
uid: seatUid,
seatCol: tileCol,
seatRow: tileRow,
facingDir,
assigned: false,
})
seatCount++
}
}
}
return seats
}
/** Get the set of tiles occupied by seats (so they can be excluded from blocked tiles) */
export function getSeatTiles(seats: Map<string, Seat>): Set<string> {
const tiles = new Set<string>()
for (const seat of seats.values()) {
tiles.add(`${Math.round(seat.seatCol)},${Math.round(seat.seatRow)}`)
}
return tiles
}
/** Default floor colors */
const DEFAULT_LEFT_ROOM_COLOR: FloorColor = { h: 35, s: 30, b: 15, c: 0 } // warm beige
const DEFAULT_RIGHT_ROOM_COLOR: FloorColor = { h: 25, s: 45, b: 5, c: 10 } // warm brown
const DEFAULT_CARPET_COLOR: FloorColor = { h: 280, s: 40, b: -5, c: 0 } // purple
const DEFAULT_DOORWAY_COLOR: FloorColor = { h: 35, s: 25, b: 10, c: 0 } // tan
const DEFAULT_LOUNGE_COLOR: FloorColor = { h: 200, s: 30, b: 10, c: 0 } // cool blue
const RIGHT_WALL_STOOLS: ReadonlyArray<PlacedFurniture> = [
// Right-office right wall: 2 vertical columns × 4 stools each
{ uid: 'stool-r1', type: FurnitureType.BENCH, col: 19, row: 3 },
{ uid: 'stool-r2', type: FurnitureType.BENCH, col: 19, row: 4.5 },
{ uid: 'stool-r3', type: FurnitureType.BENCH, col: 19, row: 6 },
{ uid: 'stool-r4', type: FurnitureType.BENCH, col: 19, row: 7.5 },
{ uid: 'stool-r5', type: FurnitureType.BENCH, col: 17, row: 3 },
{ uid: 'stool-r6', type: FurnitureType.BENCH, col: 17, row: 4.5 },
{ uid: 'stool-r7', type: FurnitureType.BENCH, col: 17, row: 6 },
{ uid: 'stool-r8', type: FurnitureType.BENCH, col: 17, row: 7.5 },
]
const LEFT_WALL_SERVER: Readonly<PlacedFurniture> = {
uid: 'server-b-left',
type: FurnitureType.SERVER_RACK,
col: 1,
row: 12,
}
function shouldRemoveRightOfficeLegacyItems(item: PlacedFurniture): boolean {
if (item.uid.startsWith('stool-r')) return true
if (item.uid === 'plant-r1' || item.uid === 'lamp-r' || item.uid === 'cooler-r') return true
if (item.type === FurnitureType.PLANT && item.col === 19 && item.row === 3) return true
if (item.type === FurnitureType.LAMP && item.col === 19 && item.row === 7) return true
if (item.type === FurnitureType.COOLER && item.col === 18 && item.row === 7) return true
return false
}
function normalizeRightOfficeFurniture(furniture: PlacedFurniture[]): PlacedFurniture[] {
const base = furniture.filter((item) => !shouldRemoveRightOfficeLegacyItems(item))
const next = [...base]
for (const stool of RIGHT_WALL_STOOLS) {
const exists = next.some((item) => item.uid === stool.uid)
if (!exists) next.push({ ...stool })
}
if (!next.some((item) => item.uid === LEFT_WALL_SERVER.uid)) {
next.push({ ...LEFT_WALL_SERVER })
}
return next
}
/** Create the default office layout — 21×17 multi-room office */
export function createDefaultLayout(): OfficeLayout {
const W = TileType.WALL
const F1 = TileType.FLOOR_1
const F2 = TileType.FLOOR_2
const F3 = TileType.FLOOR_3
const F4 = TileType.FLOOR_4
// Build tile grid row by row
// Layout: top work rooms (rows 0-9), corridor (row 10), bottom lounge/meeting (rows 11-16)
const cols = DEFAULT_COLS // 21
const rows = DEFAULT_ROWS // 17
const tiles: TileTypeVal[] = []
const tileColors: Array<FloorColor | null> = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
// Outer walls
if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) {
tiles.push(W); tileColors.push(null); continue
}
// Divider wall between left and right rooms (col 10), with doorway
if (c === 10 && r <= 9) {
if (r >= 4 && r <= 6) {
tiles.push(F4); tileColors.push(DEFAULT_DOORWAY_COLOR)
} else {
tiles.push(W); tileColors.push(null)
}
continue
}
// Horizontal wall between top rooms and bottom area (row 10), with two doorways
if (r === 10) {
if ((c >= 4 && c <= 6) || (c >= 14 && c <= 16)) {
tiles.push(F4); tileColors.push(DEFAULT_DOORWAY_COLOR)
} else {
tiles.push(W); tileColors.push(null)
}
continue
}
// Carpet area in right room (meeting corner)
if (c >= 15 && c <= 18 && r >= 7 && r <= 9) {
tiles.push(F3); tileColors.push(DEFAULT_CARPET_COLOR); continue
}
// Top rooms
if (r < 10) {
if (c < 10) {
tiles.push(F1); tileColors.push(DEFAULT_LEFT_ROOM_COLOR)
} else {
tiles.push(F2); tileColors.push(DEFAULT_RIGHT_ROOM_COLOR)
}
continue
}
// Bottom lounge area
if (c >= 8 && c <= 12 && r >= 13 && r <= 15) {
tiles.push(F3); tileColors.push(DEFAULT_CARPET_COLOR)
} else {
tiles.push(F1); tileColors.push(DEFAULT_LOUNGE_COLOR)
}
}
}
const furniture: PlacedFurniture[] = [
// ── Left work room — 4 horizontal desks in 2×2 grid ──
// Top row: benches above desks, PCs show back
{ uid: 'desk-l1', type: FurnitureType.TABLE_WOOD_SM_HORIZONTAL, col: 3, row: 3 },
{ uid: 'chair-l1', type: FurnitureType.BENCH, col: 3.5, row: 2 },
{ uid: 'desk-l2', type: FurnitureType.TABLE_WOOD_SM_HORIZONTAL, col: 6, row: 3 },
{ uid: 'chair-l2', type: FurnitureType.BENCH, col: 6.5, row: 2 },
// Bottom row: benches below desks, PCs show front
{ uid: 'desk-l3', type: FurnitureType.TABLE_WOOD_SM_HORIZONTAL, col: 3, row: 6 },
{ uid: 'chair-l3', type: FurnitureType.BENCH, col: 3.5, row: 7 },
{ uid: 'desk-l4', type: FurnitureType.TABLE_WOOD_SM_HORIZONTAL, col: 6, row: 6 },
{ uid: 'chair-l4', type: FurnitureType.BENCH, col: 6.5, row: 7 },
{ uid: 'bookshelf-l', type: FurnitureType.BOOKSHELF, col: 1, row: 5 },
{ uid: 'plant-l1', type: FurnitureType.PLANT, col: 1, row: 1 },
{ uid: 'lamp-l', type: FurnitureType.LAMP, col: 1, row: 3 },
{ uid: 'pc-l1', type: FurnitureType.PC, col: 3.5, row: 2.75, rotation: 180 },
{ uid: 'pc-l2', type: FurnitureType.PC, col: 6.5, row: 2.75, rotation: 180 },
{ uid: 'pc-l3', type: FurnitureType.PC, col: 3.5, row: 5.5 },
{ uid: 'coffee-l', type: FurnitureType.COFFEE, col: 4.17, row: 5.75 },
{ uid: 'pc-l4', type: FurnitureType.PC, col: 6.5, row: 5.5 },
{ uid: 'phone-l', type: FurnitureType.PHONE, col: 7.17, row: 5.75 },
// ── Right work room ──
{ uid: 'desk-r1', type: FurnitureType.DESK, col: 13, row: 3 },
{ uid: 'chair-r1-top', type: FurnitureType.CHAIR, col: 13, row: 2 },
{ uid: 'chair-r1-bottom', type: FurnitureType.CHAIR, col: 14, row: 5 },
{ uid: 'chair-r1-left', type: FurnitureType.CHAIR, col: 12, row: 4 },
{ uid: 'chair-r1-right', type: FurnitureType.CHAIR, col: 15, row: 3 },
{ uid: 'pc-r', type: FurnitureType.PC, col: 14, row: 2.75, rotation: 270 },
{ uid: 'camera-r', type: FurnitureType.CAMERA, col: 13.5, row: 3.5 },
{ uid: 'whiteboard-r', type: FurnitureType.WHITEBOARD, col: 15, row: 0 },
{ uid: 'library-r', type: FurnitureType.LIBRARY_GRAY_FULL, col: 17.5, row: -0.5 },
{ uid: 'clock-r', type: FurnitureType.CLOCK, col: 11, row: 0 },
...RIGHT_WALL_STOOLS,
// ── Right room meeting corner ──
{ uid: 'bench-r', type: FurnitureType.BENCH, col: 16, row: 9 },
// ── Bottom lounge / break area ──
{ uid: 'fridge-b', type: FurnitureType.FRIDGE, col: 1, row: 9.5 },
{ ...LEFT_WALL_SERVER },
{ uid: 'water-cooler-b', type: FurnitureType.WATER_COOLER, col: 8, row: 9.5 },
{ uid: 'deco-b', type: FurnitureType.DECO_3, col: 9, row: 9.5 },
{ uid: 'plant-b1', type: FurnitureType.PLANT, col: 1, row: 15 },
{ uid: 'plant-b2', type: FurnitureType.PLANT_SMALL, col: 19, row: 15 },
{ uid: 'plant-b3', type: FurnitureType.PLANT_SMALL, col: 19, row: 10.5 },
{ uid: 'painting-l2', type: FurnitureType.PAINTING_LARGE_2, col: 11, row: 10 },
{ uid: 'bookshelf-b', type: FurnitureType.BOOKSHELF, col: 18, row: 9.5 },
{ uid: 'sofa-b', type: FurnitureType.SOFA, col: 10, row: 14, rotation: 180 },
{ uid: 'bench-b1', type: FurnitureType.BENCH, col: 8, row: 15 },
{ uid: 'bench-b2', type: FurnitureType.BENCH, col: 12, row: 15 },
{ uid: 'lamp-b', type: FurnitureType.LAMP, col: 7, row: 14 },
]
return { version: 1, cols, rows, tiles, tileColors, furniture }
}
/** Serialize layout to JSON string */
export function serializeLayout(layout: OfficeLayout): string {
return JSON.stringify(layout)
}
/** Deserialize layout from JSON string, migrating old tile types if needed */
export function deserializeLayout(json: string): OfficeLayout | null {
try {
const obj = JSON.parse(json)
if (obj && obj.version === 1 && Array.isArray(obj.tiles) && Array.isArray(obj.furniture)) {
return migrateLayout(obj as OfficeLayout)
}
} catch { /* ignore parse errors */ }
return null
}
/**
* Ensure layout has tileColors. If missing, generate defaults based on tile types.
* Exported for use by message handlers that receive layouts over the wire.
*/
export function migrateLayoutColors(layout: OfficeLayout): OfficeLayout {
return migrateLayout(layout)
}
/**
* Migrate old layouts that use legacy tile types (TILE_FLOOR=1, WOOD_FLOOR=2, CARPET=3, DOORWAY=4)
* to the new pattern-based system. If tileColors is already present, no migration needed.
*/
function migrateLayout(layout: OfficeLayout): OfficeLayout {
if (layout.tileColors && layout.tileColors.length === layout.tiles.length) {
const furniture = normalizeRightOfficeFurniture(layout.furniture)
const furnitureChanged =
furniture.length !== layout.furniture.length ||
furniture.some((item, index) => item !== layout.furniture[index])
if (!furnitureChanged) return layout
return { ...layout, furniture }
}
// Check if any tiles use old values (1-4) — these map directly to FLOOR_1-4
// but need color assignments
const tileColors: Array<FloorColor | null> = []
for (const tile of layout.tiles) {
switch (tile) {
case 0: // WALL
tileColors.push(null)
break
case 1: // was TILE_FLOOR → FLOOR_1 beige
tileColors.push(DEFAULT_LEFT_ROOM_COLOR)
break
case 2: // was WOOD_FLOOR → FLOOR_2 brown
tileColors.push(DEFAULT_RIGHT_ROOM_COLOR)
break
case 3: // was CARPET → FLOOR_3 purple
tileColors.push(DEFAULT_CARPET_COLOR)
break
case 4: // was DOORWAY → FLOOR_4 tan
tileColors.push(DEFAULT_DOORWAY_COLOR)
break
default:
// New tile types (5-7) without colors — use neutral gray
tileColors.push(tile > 0 ? { h: 0, s: 0, b: 0, c: 0 } : null)
}
}
const furniture = normalizeRightOfficeFurniture(layout.furniture)
return { ...layout, tileColors, furniture }
}
// ── Interaction Points ──────────────────────────────────────────
export interface InteractionPoint {
col: number
row: number
facingDir: Direction
furnitureType: string
}
/** Furniture types that idle characters can interact with */
const INTERACTABLE_TYPES = new Set([
FurnitureType.COOLER, FurnitureType.WATER_COOLER,
FurnitureType.BOOKSHELF, FurnitureType.LIBRARY_GRAY_FULL,
FurnitureType.WHITEBOARD, FurnitureType.FRIDGE,
FurnitureType.DECO_3,
])
/** Get interaction points adjacent to interactable furniture */
export function getInteractionPoints(
furniture: PlacedFurniture[], tileMap: TileTypeVal[][], blockedTiles: Set<string>,
): InteractionPoint[] {
const points: InteractionPoint[] = []
for (const item of furniture) {
const entry = getCatalogEntry(item.type)
if (!entry || !INTERACTABLE_TYPES.has(item.type as any)) continue
// Check tiles along the bottom edge + 1 row below the furniture
for (let dc = 0; dc < entry.footprintW; dc++) {
const belowCol = Math.round(item.col + dc)
const belowRow = Math.round(item.row + entry.footprintH)
if (isWalkable(belowCol, belowRow, tileMap, blockedTiles)) {
points.push({ col: belowCol, row: belowRow, facingDir: Direction.UP, furnitureType: item.type })
}
}
// Check tiles along the left edge
for (let dr = 0; dr < entry.footprintH; dr++) {
const leftCol = Math.round(item.col - 1)
const leftRow = Math.round(item.row + dr)
if (isWalkable(leftCol, leftRow, tileMap, blockedTiles)) {
points.push({ col: leftCol, row: leftRow, facingDir: Direction.RIGHT, furnitureType: item.type })
}
}
// Check tiles along the right edge
for (let dr = 0; dr < entry.footprintH; dr++) {
const rightCol = Math.round(item.col + entry.footprintW)
const rightRow = Math.round(item.row + dr)
if (isWalkable(rightCol, rightRow, tileMap, blockedTiles)) {
points.push({ col: rightCol, row: rightRow, facingDir: Direction.LEFT, furnitureType: item.type })
}
}
}
// Photograph interaction points — idle characters can stop to admire it (row 1, facing up)
// Avoid cols 15-16 which overlap with whiteboard-r interaction points
// Duplicate points to increase weight in the random selection pool
for (let c = 12; c <= 14; c++) {
if (isWalkable(c, 1, tileMap, blockedTiles)) {
points.push({ col: c, row: 1, facingDir: Direction.UP, furnitureType: 'photograph' })
points.push({ col: c, row: 1, facingDir: Direction.UP, furnitureType: 'photograph' })
}
}
return points
}
// ── Doorway Tiles ───────────────────────────────────────────────
/** Find all doorway (FLOOR_4) tiles in the layout */
export function getDoorwayTiles(layout: OfficeLayout): Array<{ col: number; row: number }> {
const tiles: Array<{ col: number; row: number }> = []
for (let r = 0; r < layout.rows; r++) {
for (let c = 0; c < layout.cols; c++) {
if (layout.tiles[r * layout.cols + c] === TileType.FLOOR_4) {
tiles.push({ col: c, row: r })
}
}
}
return tiles
}