diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 47e09d3..78564ed 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -221,6 +221,7 @@ export default function PixelOfficePage() { const panRef = useRef<{ x: number; y: number }>(cachedPan) const savedLayoutRef = useRef(cachedSavedLayout) const animationFrameIdRef = useRef(null) + const officeReadyRef = useRef(false) const prevAgentStatesRef = useRef>(new Map(cachedPrevAgentStates)) const seenSubagentEventKeysRef = useRef>(new Map()) @@ -382,6 +383,10 @@ export default function PixelOfficePage() { return () => mql.removeEventListener("change", apply) }, []) + useEffect(() => { + officeReadyRef.current = officeReady + }, [officeReady]) + // Load saved layout and sound preference useEffect(() => { const loadLayout = async () => { @@ -443,6 +448,23 @@ export default function PixelOfficePage() { } }, []) + useEffect(() => { + const office = officeRef.current + if (!officeReady || !office || cachedAgents.length === 0) return + + for (const [agentId, charId] of agentIdMapRef.current) { + office.removeAllSubagents(charId) + office.removeAgent(charId) + agentIdMapRef.current.delete(agentId) + } + nextIdRef.current.current = 1 + + setAgents(cachedAgents) + syncAgentsToOffice(cachedAgents, office, agentIdMapRef.current, nextIdRef.current) + cachedAgentIdMap = new Map(agentIdMapRef.current) + cachedNextCharacterId = nextIdRef.current.current + }, [officeReady]) + useEffect(() => { if (soundOn) { void playBackgroundMusic() @@ -718,7 +740,7 @@ export default function PixelOfficePage() { useEffect(() => { if (cachedAgents.length > 0) { setAgents(cachedAgents) - if (officeRef.current) { + if (officeRef.current && officeReadyRef.current) { syncAgentsToOffice(cachedAgents, officeRef.current, agentIdMapRef.current, nextIdRef.current) } } @@ -731,7 +753,7 @@ export default function PixelOfficePage() { cachedAgents = newAgents const office = officeRef.current - if (office) { + if (office && officeReadyRef.current) { syncAgentsToOffice(newAgents, office, agentIdMapRef.current, nextIdRef.current) cachedAgentIdMap = new Map(agentIdMapRef.current) cachedNextCharacterId = nextIdRef.current.current diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 2c71a30..367056b 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -10,27 +10,216 @@ const BUGS_ENABLED_KEY = "pixel-office-bugs-enabled"; const BUGS_COUNT_KEY = "pixel-office-bugs-count"; const BUGS_MAX = 400; -const NAV_ITEMS = [ +type NavIconName = "agents" | "pixelOffice" | "models" | "sessions" | "stats" | "alerts" | "skills"; +type PixelTone = "base" | "shade" | "light"; +type PixelRect = { x: number; y: number; w?: number; h?: number; tone?: PixelTone; opacity?: number }; +type PixelPalette = { base: string; shade: string; light: string }; + +function PixelSvg({ pixels, className, palette }: { pixels: PixelRect[]; className?: string; palette: PixelPalette }) { + const fillForTone = (tone: PixelTone = "base") => { + if (tone === "shade") return palette.shade; + if (tone === "light") return palette.light; + return palette.base; + }; + + return ( + + ); +} + +function navPalette(active: boolean): PixelPalette { + return active + ? { base: "var(--accent)", shade: "color-mix(in srgb, var(--accent) 72%, black)", light: "color-mix(in srgb, var(--accent) 55%, white)" } + : { base: "var(--text)", shade: "color-mix(in srgb, var(--text) 62%, black)", light: "color-mix(in srgb, var(--text) 35%, white)" }; +} + +function NavPixelIcon({ name, active }: { name: NavIconName; active: boolean }) { + const baseClass = "h-4 w-4"; + const palette = navPalette(active); + + switch (name) { + case "agents": + return ( + + ); + case "pixelOffice": + return ( + + ); + case "models": + return ( + + ); + case "sessions": + return ( + + ); + case "stats": + return ( + + ); + case "alerts": + return ( + + ); + case "skills": + return ( + + ); + } +} + +function NavItemIcon({ name, active }: { name: NavIconName; active: boolean }) { + return ( + + + + ); +} + +const NAV_ITEMS: { group: string; items: { href: string; icon: NavIconName; labelKey: string }[] }[] = [ { group: "nav.overview", items: [ - { href: "/", icon: "🤖", labelKey: "nav.agents" }, - { href: "/pixel-office", icon: "🎮", labelKey: "nav.pixelOffice" }, - { href: "/models", icon: "🧠", labelKey: "nav.models" }, + { href: "/", icon: "agents", labelKey: "nav.agents" }, + { href: "/pixel-office", icon: "pixelOffice", labelKey: "nav.pixelOffice" }, + { href: "/models", icon: "models", labelKey: "nav.models" }, ], }, { group: "nav.monitor", items: [ - { href: "/sessions", icon: "💬", labelKey: "nav.sessions" }, - { href: "/stats", icon: "📊", labelKey: "nav.stats" }, - { href: "/alerts", icon: "🔔", labelKey: "nav.alerts" }, + { href: "/sessions", icon: "sessions", labelKey: "nav.sessions" }, + { href: "/stats", icon: "stats", labelKey: "nav.stats" }, + { href: "/alerts", icon: "alerts", labelKey: "nav.alerts" }, ], }, { group: "nav.config", items: [ - { href: "/skills", icon: "🧩", labelKey: "nav.skills" }, + { href: "/skills", icon: "skills", labelKey: "nav.skills" }, ], }, ]; @@ -341,7 +530,7 @@ export function Sidebar() { : "text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg)]" }`} > - {item.icon} + {t(item.labelKey)} ); @@ -513,7 +702,7 @@ export function Sidebar() { gap: collapsed ? 0 : 10, }} > - {item.icon} + {!collapsed && t(item.labelKey)} ); diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 457dab1..a448fc5 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -1,6 +1,5 @@ import { TILE_SIZE, MATRIX_EFFECT_DURATION, CharacterState, Direction } from '../types' import { - PALETTE_COUNT, HUE_SHIFT_MIN_DEG, HUE_SHIFT_RANGE_DEG, WAITING_BUBBLE_DURATION_SEC, @@ -15,6 +14,7 @@ import { } from '../constants' import type { Character, Seat, FurnitureInstance, TileType as TileTypeVal, OfficeLayout, PlacedFurniture } from '../types' import { createCharacter, updateCharacter } from './characters' +import { CHARACTER_PALETTES, getAvailableCharacterVariantCount } from '../sprites/spriteData' import { matrixEffectSeeds } from './matrixEffect' import { isWalkable, getWalkableTiles, findPath } from '../layout/tileMap' import { @@ -371,23 +371,26 @@ export class OfficeState { /** * Pick a diverse palette for a new agent based on currently active agents. - * First 6 agents each get a unique skin (random order). Beyond 6, skins + * First round uses each available character variant once (random order). + * Beyond that, variants * repeat in balanced rounds with a random hue shift (≥45°). */ private pickDiversePalette(): { palette: number; hueShift: number } { - // Count how many non-sub-agents use each base palette (0-5) - const counts = new Array(PALETTE_COUNT).fill(0) as number[] + const paletteCount = getAvailableCharacterVariantCount() + const counts = new Array(paletteCount).fill(0) as number[] for (const ch of this.characters.values()) { if (ch.isSubagent) continue - counts[ch.palette]++ + counts[ch.palette % paletteCount]++ } const minCount = Math.min(...counts) - // Available = palettes at the minimum count (least used) + // Available = variants at the minimum count (least used) const available: number[] = [] - for (let i = 0; i < PALETTE_COUNT; i++) { + for (let i = 0; i < paletteCount; i++) { if (counts[i] === minCount) available.push(i) } - const palette = available[Math.floor(Math.random() * available.length)] + const extraVariants = available.filter((index) => index >= CHARACTER_PALETTES.length) + const preferred = minCount === 0 && extraVariants.length > 0 ? extraVariants : available + const palette = preferred[Math.floor(Math.random() * preferred.length)] // First round (minCount === 0): no hue shift. Subsequent rounds: random ≥45°. let hueShift = 0 if (minCount > 0) { diff --git a/lib/pixel-office/sprites/pngLoader.ts b/lib/pixel-office/sprites/pngLoader.ts index 68d3a74..53fa6ee 100644 --- a/lib/pixel-office/sprites/pngLoader.ts +++ b/lib/pixel-office/sprites/pngLoader.ts @@ -7,13 +7,9 @@ import { setWallSprites } from '../wallTiles' * Load a PNG image and convert it to SpriteData (2D array of hex color strings). * Transparent pixels become '' (empty string). */ -function pngToSpriteData(img: HTMLImageElement): SpriteData { - const canvas = document.createElement('canvas') - canvas.width = img.width - canvas.height = img.height +function canvasToSpriteData(canvas: HTMLCanvasElement): SpriteData { const ctx = canvas.getContext('2d')! - ctx.drawImage(img, 0, 0) - const imageData = ctx.getImageData(0, 0, img.width, img.height) + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) const { data, width, height } = imageData const result: string[][] = [] @@ -33,6 +29,15 @@ function pngToSpriteData(img: HTMLImageElement): SpriteData { return result } +function pngToSpriteData(img: HTMLImageElement): SpriteData { + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d')! + ctx.drawImage(img, 0, 0) + return canvasToSpriteData(canvas) +} + function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image() @@ -42,6 +47,76 @@ function loadImage(src: string): Promise { }) } +function normalizedSpriteData(img: HTMLImageElement, targetWidth?: number, targetHeight?: number): SpriteData { + const canvas = document.createElement('canvas') + const shouldResize = + typeof targetWidth === 'number' && + typeof targetHeight === 'number' && + img.width !== targetWidth && + img.height !== targetHeight && + img.width % targetWidth === 0 && + img.height % targetHeight === 0 + + if (shouldResize) { + canvas.width = targetWidth + canvas.height = targetHeight + const ctx = canvas.getContext('2d')! + ctx.imageSmoothingEnabled = false + ctx.drawImage(img, 0, 0, targetWidth, targetHeight) + return canvasToSpriteData(canvas) + } + + return pngToSpriteData(img) +} + +function stripOpaqueSheetBackground(sprite: SpriteData): SpriteData { + if (sprite.length === 0 || sprite[0].length === 0) return sprite + if (sprite.some((row) => row.some((pixel) => pixel === ''))) return sprite + + const height = sprite.length + const width = sprite[0].length + const result = sprite.map((row) => [...row]) + const visited = Array.from({ length: height }, () => Array(width).fill(false)) + const queue: Array<[number, number]> = [] + + const brightness = (hex: string): number => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return 0.299 * r + 0.587 * g + 0.114 * b + } + + const corners = [sprite[0][0], sprite[0][width - 1], sprite[height - 1][0], sprite[height - 1][width - 1]] + const threshold = Math.max(140, Math.min(...corners.map((pixel) => brightness(pixel))) - 12) + + const enqueue = (x: number, y: number) => { + if (visited[y][x]) return + if (brightness(result[y][x]) < threshold) return + visited[y][x] = true + queue.push([x, y]) + } + + for (let x = 0; x < width; x++) { + enqueue(x, 0) + enqueue(x, height - 1) + } + for (let y = 0; y < height; y++) { + enqueue(0, y) + enqueue(width - 1, y) + } + + while (queue.length > 0) { + const [x, y] = queue.shift()! + result[y][x] = '' + if (x > 0) enqueue(x - 1, y) + if (x + 1 < width) enqueue(x + 1, y) + if (y > 0) enqueue(x, y - 1) + if (y + 1 < height) enqueue(x, y + 1) + } + + return result +} + /** * Extract a sub-region from a SpriteData array. */ @@ -74,16 +149,33 @@ function parseCharacterSheet(sheet: SpriteData): LoadedCharacterData { /** * Load character PNGs from /assets/pixel-office/characters/ and register them. - * Falls back silently to hardcoded templates if loading fails. + * Loads the default set plus any extra contiguous char_N.png files. + * Falls back silently to hardcoded templates if the base set fails. */ export async function loadCharacterPNGs(): Promise { try { const characters: LoadedCharacterData[] = [] - for (let i = 0; i < 6; i++) { + const baseCharacterCount = 6 + const maxCharacterCount = 64 + const CHARACTER_SHEET_WIDTH = 112 + const CHARACTER_SHEET_HEIGHT = 96 + + for (let i = 0; i < baseCharacterCount; i++) { const img = await loadImage(`/assets/pixel-office/characters/char_${i}.png`) - const sheet = pngToSpriteData(img) + const sheet = stripOpaqueSheetBackground(normalizedSpriteData(img, CHARACTER_SHEET_WIDTH, CHARACTER_SHEET_HEIGHT)) characters.push(parseCharacterSheet(sheet)) } + + for (let i = baseCharacterCount; i < maxCharacterCount; i++) { + try { + const img = await loadImage(`/assets/pixel-office/characters/char_${i}.png`) + const sheet = stripOpaqueSheetBackground(normalizedSpriteData(img, CHARACTER_SHEET_WIDTH, CHARACTER_SHEET_HEIGHT)) + characters.push(parseCharacterSheet(sheet)) + } catch { + break + } + } + setCharacterTemplates(characters) return true } catch (e) { diff --git a/lib/pixel-office/sprites/spriteData.ts b/lib/pixel-office/sprites/spriteData.ts index 5c325ae..9c2fcb7 100644 --- a/lib/pixel-office/sprites/spriteData.ts +++ b/lib/pixel-office/sprites/spriteData.ts @@ -1123,6 +1123,10 @@ export interface LoadedCharacterData { let loadedCharacters: LoadedCharacterData[] | null = null +export function getAvailableCharacterVariantCount(): number { + return loadedCharacters?.length ?? CHARACTER_PALETTES.length +} + /** Set pre-colored character sprites loaded from PNG assets. Call this when characterSpritesLoaded message arrives. */ export function setCharacterTemplates(data: LoadedCharacterData[]): void { loadedCharacters = data diff --git a/public/assets/pixel-office/characters/char_6.png b/public/assets/pixel-office/characters/char_6.png new file mode 100644 index 0000000..5bb7a5a Binary files /dev/null and b/public/assets/pixel-office/characters/char_6.png differ diff --git a/public/assets/pixel-office/characters/char_7.png b/public/assets/pixel-office/characters/char_7.png new file mode 100644 index 0000000..272ff6d Binary files /dev/null and b/public/assets/pixel-office/characters/char_7.png differ diff --git a/public/assets/pixel-office/characters/char_8.png b/public/assets/pixel-office/characters/char_8.png new file mode 100644 index 0000000..76142d8 Binary files /dev/null and b/public/assets/pixel-office/characters/char_8.png differ