Update pixel office characters and nav icons

This commit is contained in:
xmanrui
2026-03-09 03:41:32 +08:00
parent a39b8bfc98
commit 9c14282cba
8 changed files with 339 additions and 29 deletions
+24 -2
View File
@@ -221,6 +221,7 @@ export default function PixelOfficePage() {
const panRef = useRef<{ x: number; y: number }>(cachedPan)
const savedLayoutRef = useRef<OfficeLayout | null>(cachedSavedLayout)
const animationFrameIdRef = useRef<number | null>(null)
const officeReadyRef = useRef<boolean>(false)
const prevAgentStatesRef = useRef<Map<string, string>>(new Map(cachedPrevAgentStates))
const seenSubagentEventKeysRef = useRef<Map<string, number>>(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
+199 -10
View File
@@ -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 (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={className}
style={{ imageRendering: "pixelated", shapeRendering: "crispEdges" }}
>
{pixels.map((pixel, index) => (
<rect
key={index}
x={pixel.x}
y={pixel.y}
width={pixel.w ?? 1}
height={pixel.h ?? 1}
fill={fillForTone(pixel.tone)}
opacity={pixel.opacity ?? 1}
/>
))}
</svg>
);
}
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 (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 5, y: 1, w: 6, h: 1, tone: "light" },
{ x: 4, y: 2, w: 8, h: 1, tone: "base" },
{ x: 3, y: 3, w: 10, h: 2, tone: "base" },
{ x: 4, y: 5, w: 8, h: 1, tone: "shade" },
{ x: 2, y: 7, w: 12, h: 1, tone: "base" },
{ x: 1, y: 8, w: 3, h: 5, tone: "shade" },
{ x: 6, y: 8, w: 4, h: 2, tone: "light", opacity: 0.95 },
{ x: 12, y: 8, w: 3, h: 5, tone: "shade" },
{ x: 5, y: 10, w: 6, h: 4, tone: "base" },
{ x: 6, y: 6, w: 1, h: 1, tone: "light", opacity: 0.65 },
{ x: 9, y: 6, w: 1, h: 1, tone: "light", opacity: 0.65 },
]}
/>
);
case "pixelOffice":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 1, y: 4, w: 14, h: 1, tone: "shade" },
{ x: 1, y: 5, w: 2, h: 8, tone: "base" },
{ x: 13, y: 5, w: 2, h: 8, tone: "base" },
{ x: 5, y: 1, w: 6, h: 2, tone: "light" },
{ x: 4, y: 3, w: 8, h: 1, tone: "base" },
{ x: 4, y: 7, w: 3, h: 3, tone: "shade" },
{ x: 9, y: 7, w: 3, h: 3, tone: "shade" },
{ x: 6, y: 10, w: 4, h: 3, tone: "light", opacity: 0.9 },
]}
/>
);
case "models":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 5, y: 1, w: 6, h: 1, tone: "light" },
{ x: 3, y: 3, w: 10, h: 1, tone: "base" },
{ x: 2, y: 5, w: 12, h: 1, tone: "shade" },
{ x: 3, y: 7, w: 10, h: 1, tone: "base" },
{ x: 5, y: 9, w: 6, h: 1, tone: "light" },
{ x: 4, y: 2, w: 1, h: 1, tone: "light", opacity: 0.8 },
{ x: 11, y: 2, w: 1, h: 1, tone: "light", opacity: 0.8 },
{ x: 1, y: 4, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 14, y: 4, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 4, y: 11, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 11, y: 11, w: 1, h: 2, tone: "shade", opacity: 0.9 },
{ x: 6, y: 13, w: 4, h: 1, tone: "base" },
]}
/>
);
case "sessions":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 2, y: 3, w: 10, h: 7, tone: "base" },
{ x: 4, y: 10, w: 4, h: 2, tone: "shade" },
{ x: 10, y: 10, w: 2, h: 2, tone: "shade" },
{ x: 4, y: 5, w: 6, h: 1, tone: "light", opacity: 0.6 },
{ x: 4, y: 7, w: 4, h: 1, tone: "light", opacity: 0.6 },
{ x: 11, y: 6, w: 3, h: 5, tone: "shade", opacity: 0.9 },
{ x: 12, y: 5, w: 2, h: 1, tone: "light", opacity: 0.85 },
]}
/>
);
case "stats":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 2, y: 12, w: 12, h: 1, tone: "shade", opacity: 0.7 },
{ x: 3, y: 9, w: 2, h: 3, tone: "base" },
{ x: 7, y: 6, w: 2, h: 6, tone: "base" },
{ x: 11, y: 3, w: 2, h: 9, tone: "light" },
{ x: 2, y: 4, w: 2, h: 2, tone: "shade", opacity: 0.65 },
{ x: 5, y: 2, w: 2, h: 2, tone: "light", opacity: 0.65 },
]}
/>
);
case "alerts":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 7, y: 1, w: 2, h: 1, tone: "light" },
{ x: 5, y: 2, w: 6, h: 1, tone: "base" },
{ x: 4, y: 3, w: 8, h: 1, tone: "base" },
{ x: 4, y: 4, w: 8, h: 5, tone: "shade" },
{ x: 3, y: 9, w: 10, h: 2, tone: "base" },
{ x: 6, y: 12, w: 4, h: 1, tone: "light" },
{ x: 5, y: 13, w: 6, h: 1, tone: "shade", opacity: 0.8 },
]}
/>
);
case "skills":
return (
<PixelSvg
className={baseClass}
palette={palette}
pixels={[
{ x: 6, y: 1, w: 4, h: 2, tone: "light" },
{ x: 4, y: 3, w: 8, h: 2, tone: "base" },
{ x: 2, y: 5, w: 12, h: 6, tone: "shade" },
{ x: 4, y: 11, w: 8, h: 2, tone: "base" },
{ x: 6, y: 13, w: 4, h: 2, tone: "light" },
{ x: 7, y: 6, w: 2, h: 4, tone: "base", opacity: 0.5 },
{ x: 5, y: 7, w: 6, h: 2, tone: "light", opacity: 0.45 },
]}
/>
);
}
}
function NavItemIcon({ name, active }: { name: NavIconName; active: boolean }) {
return (
<span
className={`inline-flex h-8 w-8 items-center justify-center border transition-colors ${
active
? "border-[var(--accent)]/45 bg-[var(--accent)]/14"
: "border-[var(--border)] bg-[var(--bg)]/88"
}`}
style={{
borderRadius: 0,
boxShadow: active
? "inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -2px 0 rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.04)"
: "inset 0 1px 0 rgba(255,255,255,0.08), inset 0 -2px 0 rgba(0,0,0,0.1)",
}}
>
<NavPixelIcon name={name} active={active} />
</span>
);
}
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)]"
}`}
>
<span className="text-base">{item.icon}</span>
<NavItemIcon name={item.icon} active={active} />
{t(item.labelKey)}
</Link>
);
@@ -513,7 +702,7 @@ export function Sidebar() {
gap: collapsed ? 0 : 10,
}}
>
<span className="text-base">{item.icon}</span>
<NavItemIcon name={item.icon} active={active} />
{!collapsed && t(item.labelKey)}
</Link>
);
+11 -8
View File
@@ -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) {
+101 -9
View File
@@ -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<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
@@ -42,6 +47,76 @@ function loadImage(src: string): Promise<HTMLImageElement> {
})
}
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<boolean> {
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) {
+4
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB