From 4087e4135c40d91e2ebcfe2d5807f1ad9f3f8bb8 Mon Sep 17 00:00:00 2001 From: Edward Date: Mon, 24 Aug 2026 19:25:11 +0200 Subject: [PATCH] feat(ui): let players pick a hero class and color per agent Persist the choice in localStorage keyed by source + cwd + name so a new session in the same project keeps the look. Overlay clicks no longer deselect the detail panel (z-index above toasts + ignore Phaser hits that did not land on the canvas). --- client/src/App.tsx | 34 ++++++--- client/src/components/DetailPanel.css | 6 +- client/src/components/DetailPanel.tsx | 29 ++++++- client/src/components/HeroLookPicker.css | 94 +++++++++++++++++++++++ client/src/components/HeroLookPicker.tsx | 76 +++++++++++++++++++ client/src/game/entities/HeroSprite.ts | 2 + client/src/game/scenes/VillageScene.ts | 76 +++++++++++++------ client/src/heroLooks.test.ts | 96 ++++++++++++++++++++++++ client/src/heroLooks.ts | 61 +++++++++++++++ client/src/hooks/useHeroLooks.ts | 65 ++++++++++++++++ 10 files changed, 501 insertions(+), 38 deletions(-) create mode 100644 client/src/components/HeroLookPicker.css create mode 100644 client/src/components/HeroLookPicker.tsx create mode 100644 client/src/heroLooks.test.ts create mode 100644 client/src/heroLooks.ts create mode 100644 client/src/hooks/useHeroLooks.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index c5f9ab0..d98a1b5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { PhaserGame } from './game/PhaserGame'; import { useAgentState } from './hooks/useAgentState'; import { useSelectedAgent } from './hooks/useSelectedAgent'; @@ -15,10 +15,17 @@ import { Toasts, type ToastItem } from './components/Toasts'; import type { NotificationEntry } from './components/NotificationMenu'; import { useSettings } from './hooks/useSettings'; import { useAgentNotifications, type ToastPayload } from './hooks/useAgentNotifications'; +import { applyHeroLook, heroLookKey } from './heroLooks'; +import { useHeroLooks } from './hooks/useHeroLooks'; import './App.css'; export default function App() { const { agents, activityLog, connected, configDirs } = useAgentState(); + const [looks, upsertLook] = useHeroLooks(); + const displayAgents = useMemo( + () => agents.map((a) => applyHeroLook(a, looks[heroLookKey(a)])), + [agents, looks], + ); const { selectedAgentId, selectAgent } = useSelectedAgent(); const [selectedBuilding, setSelectedBuilding] = useState<{ id: string; @@ -73,13 +80,13 @@ export default function App() { }, []); const selectedAgent = selectedAgentId !== null - ? agents.find((a) => a.id === selectedAgentId) ?? null + ? displayAgents.find((a) => a.id === selectedAgentId) ?? null : null; // Only show source badges when both providers have a LIVE agent — completed // / error sessions don't count, otherwise the badge would linger after the // last Codex hero finishes just because it's still in state. - const liveAgents = agents.filter((a) => a.status !== 'completed' && a.status !== 'error'); + const liveAgents = displayAgents.filter((a) => a.status !== 'completed' && a.status !== 'error'); const showSourceBadge = liveAgents.some((a) => a.source === 'claude') && liveAgents.some((a) => a.source === 'codex'); @@ -159,8 +166,8 @@ export default function App() { }, []); useEffect(() => { - eventBridge.emit('agents:updated', agents); - }, [agents]); + eventBridge.emit('agents:updated', displayAgents); + }, [displayAgents]); useEffect(() => { eventBridge.emit('selection:changed', selectedAgentId); @@ -180,7 +187,7 @@ export default function App() { {villageReady && (
{selectedAgent !== null && ( - handleSelectAgent(null)} showSourceBadge={showSourceBadge} /> + handleSelectAgent(null)} + showSourceBadge={showSourceBadge} + lookIsCustom={looks[heroLookKey(selectedAgent)] !== undefined} + onChangeLook={(look) => upsertLook(heroLookKey(selectedAgent), look)} + onResetLook={() => upsertLook(heroLookKey(selectedAgent), null)} + /> )} {selectedBuilding !== null && ( setSelectedBuilding(null)} /> )} void; showSourceBadge: boolean; + lookIsCustom: boolean; + onChangeLook: (look: { heroClass: HeroClass; heroColor: HeroColor }) => void; + onResetLook: () => void; } function formatDuration(startMs: number): string { @@ -37,7 +41,14 @@ function PathValue({ path, cwd, className }: { path: string; cwd: string; classN ); } -export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProps) { +export function DetailPanel({ + agent, + onClose, + showSourceBadge, + lookIsCustom, + onChangeLook, + onResetLook, +}: DetailPanelProps) { // Tick every second so duration-derived values refresh between server pushes. const [, setTick] = useState(0); useEffect(() => { @@ -76,7 +87,10 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp return ( <> -
+
e.stopPropagation()} + >
@@ -125,6 +139,15 @@ export function DetailPanel({ agent, onClose, showSourceBadge }: DetailPanelProp
) : (
+
+
Hero
+ +
Status
diff --git a/client/src/components/HeroLookPicker.css b/client/src/components/HeroLookPicker.css new file mode 100644 index 0000000..1065c76 --- /dev/null +++ b/client/src/components/HeroLookPicker.css @@ -0,0 +1,94 @@ +.hero-look-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hero-look-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.hero-look-picker-label { + color: #C4A35A; + font-family: 'Cinzel', serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hero-look-reset { + background: none; + border: 1px solid rgba(196, 163, 90, 0.35); + color: #C4A35A; + font-family: inherit; + font-size: 11px; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; +} +.hero-look-reset:hover { background: rgba(196, 163, 90, 0.15); } +.hero-look-reset:focus-visible { + outline: 2px solid #C4A35A; + outline-offset: 1px; +} + +.hero-look-classes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.hero-look-class { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px 5px; + background: rgba(196, 163, 90, 0.06); + border: 1px solid rgba(196, 163, 90, 0.22); + border-radius: 4px; + color: #ddd; + font-family: inherit; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; +} +.hero-look-class:hover { background: rgba(196, 163, 90, 0.14); } +.hero-look-class.selected { + border-color: #C4A35A; + background: rgba(196, 163, 90, 0.2); + color: #F5E6C8; +} +.hero-look-class:focus-visible { + outline: 2px solid #C4A35A; + outline-offset: 1px; +} + +.hero-look-colors { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.hero-look-swatch { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(26, 26, 46, 0.9); + box-shadow: 0 0 0 1px rgba(196, 163, 90, 0.35); + cursor: pointer; + padding: 0; +} +.hero-look-swatch:hover { box-shadow: 0 0 0 2px rgba(196, 163, 90, 0.7); } +.hero-look-swatch.selected { + box-shadow: 0 0 0 2px #C4A35A; +} +.hero-look-swatch:focus-visible { + outline: 2px solid #C4A35A; + outline-offset: 2px; +} diff --git a/client/src/components/HeroLookPicker.tsx b/client/src/components/HeroLookPicker.tsx new file mode 100644 index 0000000..d43277f --- /dev/null +++ b/client/src/components/HeroLookPicker.tsx @@ -0,0 +1,76 @@ +import { + HERO_CLASSES, + HERO_COLORS, + HERO_LABEL_COLOR, + type AgentState, + type HeroClass, + type HeroColor, +} from '../types/agent'; +import { HeroAvatar } from './HeroAvatar'; +import './HeroLookPicker.css'; + +interface HeroLookPickerProps { + agent: AgentState; + isCustom: boolean; + onChange: (look: { heroClass: HeroClass; heroColor: HeroColor }) => void; + onReset: () => void; +} + +const CLASS_LABEL: Record = { + warrior: 'Warrior', + archer: 'Archer', + pawn: 'Pawn', +}; + +export function HeroLookPicker({ agent, isCustom, onChange, onReset }: HeroLookPickerProps) { + return ( +
+
+ Appearance + {isCustom && ( + + )} +
+ +
+ {HERO_CLASSES.map((cls) => { + const selected = agent.heroClass === cls; + const preview = { ...agent, heroClass: cls }; + return ( + + ); + })} +
+ +
+ {HERO_COLORS.map((color) => { + const selected = agent.heroColor === color; + return ( +
+
+ ); +} diff --git a/client/src/game/entities/HeroSprite.ts b/client/src/game/entities/HeroSprite.ts index 6a6231e..6d24132 100644 --- a/client/src/game/entities/HeroSprite.ts +++ b/client/src/game/entities/HeroSprite.ts @@ -63,6 +63,7 @@ function ensureHaloTexture(scene: Phaser.Scene): void { export class HeroSprite { readonly id: string; readonly heroClass: HeroClass; + readonly heroColor: HeroColor; private scene: Phaser.Scene; private sprite: Phaser.GameObjects.Sprite; private nameText: Phaser.GameObjects.Text; @@ -114,6 +115,7 @@ export class HeroSprite { this.scene = scene; this.id = id; this.heroClass = heroClass; + this.heroColor = heroColor; this.source = source; this.isSubagent = isSubagent; this._x = x; diff --git a/client/src/game/scenes/VillageScene.ts b/client/src/game/scenes/VillageScene.ts index 2ef7fdd..1eba106 100644 --- a/client/src/game/scenes/VillageScene.ts +++ b/client/src/game/scenes/VillageScene.ts @@ -26,6 +26,16 @@ function zoomAroundPointer(cam: Phaser.Cameras.Scene2D.Camera, sx: number, sy: n cam.scrollY += before.y - after.y; } +/** True when the native event landed on the Phaser canvas, not a React overlay. + * Phaser still fires scene `pointerdown` for overlay clicks (window-level + * listeners / full-viewport canvas), and an empty hit list would otherwise + * look like a map-background click and deselect the open detail panel. */ +function isCanvasPointer(pointer: Phaser.Input.Pointer, canvas: HTMLCanvasElement): boolean { + const target = pointer.event?.target; + if (!(target instanceof Node)) return true; + return target === canvas || canvas.contains(target); +} + /** Hide agents idle for longer than this from the Phaser scene (kept in PartyBar). */ const IDLE_HIDE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours @@ -36,6 +46,7 @@ const GRID_SPACING_Y = 35; export class VillageScene extends Phaser.Scene { private buildings: Building[] = []; private heroes = new Map(); + private selectedHeroId: string | null = null; private onAgentsUpdated: ((agents: unknown) => void) | null = null; private onCameraFollow: ((agentId: unknown) => void) | null = null; private onSelectionChanged: ((agentId: unknown) => void) | null = null; @@ -328,6 +339,7 @@ export class VillageScene extends Phaser.Scene { // Apply outline/tint to the selected hero whenever selection changes. this.onSelectionChanged = (agentId: unknown) => { const selectedId = typeof agentId === 'string' ? agentId : null; + this.selectedHeroId = selectedId; for (const [id, hero] of this.heroes) { hero.setSelected(id === selectedId); } @@ -340,7 +352,8 @@ export class VillageScene extends Phaser.Scene { // meant clicking a building wiped its info panel the moment it opened // (App.handleSelectAgent(null) clears `selectedBuildingId` too). Now the // building's own pointerdown is responsible for switching selection. - this.onBackgroundPointerDown = (_p, hits) => { + this.onBackgroundPointerDown = (pointer, hits) => { + if (!isCanvasPointer(pointer, this.game.canvas)) return; if (hits.length === 0) { eventBridge.emit('hero:clicked', null); } @@ -629,6 +642,33 @@ export class VillageScene extends Phaser.Scene { } } + private placeHero(agent: AgentState, x: number, y: number): HeroSprite { + const hero = new HeroSprite( + this, + agent.id, + agent.name, + agent.heroClass, + agent.heroColor, + x, + y, + agent.id.startsWith('agent-'), + agent.source, + ); + hero.setHeroScale(this.heroScale); + hero.setActivity(agent.currentActivity); + hero.setStatus(agent.status); + hero.setErrorTimestamp(agent.lastErrorAt); + hero.updateDetail(agent.currentFile, agent.currentCommand); + hero.updateTask(agent.currentTask); + hero.setModel(agent.model); + hero.setInteractiveForSelection(() => { + eventBridge.emit('hero:clicked', agent.id); + }); + hero.setSelected(agent.id === this.selectedHeroId); + this.heroes.set(agent.id, hero); + return hero; + } + // --------------------------------------------------------------------------- // Agent update handler // --------------------------------------------------------------------------- @@ -675,33 +715,21 @@ export class VillageScene extends Phaser.Scene { const buildingsToReposition = new Set(); for (const agent of visible) { - const existing = this.heroes.get(agent.id); + let existing = this.heroes.get(agent.id); const buildingDef = getBuildingForActivity(agent.currentActivity); + if (existing !== undefined + && (existing.heroClass !== agent.heroClass || existing.heroColor !== agent.heroColor)) { + const x = existing.x; + const y = existing.y; + existing.destroy(); + this.heroes.delete(agent.id); + existing = this.placeHero(agent, x, y); + } + if (existing === undefined) { // New hero: spawn at configured spawn point then assign to building - const hero = new HeroSprite( - this, - agent.id, - agent.name, - agent.heroClass, - agent.heroColor, - this.heroSpawn.x, - this.heroSpawn.y, - agent.id.startsWith('agent-'), - agent.source, - ); - hero.setHeroScale(this.heroScale); - hero.setActivity(agent.currentActivity); - hero.setStatus(agent.status); - hero.setErrorTimestamp(agent.lastErrorAt); - hero.updateDetail(agent.currentFile, agent.currentCommand); - hero.updateTask(agent.currentTask); - hero.setModel(agent.model); - this.heroes.set(agent.id, hero); - hero.setInteractiveForSelection(() => { - eventBridge.emit('hero:clicked', agent.id); - }); + this.placeHero(agent, this.heroSpawn.x, this.heroSpawn.y); this.addToSlot(buildingDef.id, agent.id); buildingsToReposition.add(buildingDef.id); } else { diff --git a/client/src/heroLooks.test.ts b/client/src/heroLooks.test.ts new file mode 100644 index 0000000..d7f5d6f --- /dev/null +++ b/client/src/heroLooks.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'bun:test'; +import type { AgentState } from './types/agent'; +import { + applyHeroLook, + heroLookKey, + parseHeroLooks, + serializeHeroLooks, +} from './heroLooks'; + +function agent(over: Partial = {}): AgentState { + return { + id: 'sess-1', + name: 'Agent-Quest', + heroClass: 'warrior', + heroColor: 'blue', + status: 'active', + currentActivity: 'idle', + tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: 0, + sessionStart: 1000, + toolCalls: [], + errors: [], + filesModified: [], + lastEvent: 1000, + cwd: '/proj/Agent-Quest', + configDir: '/Users/x/.claude', + source: 'claude', + ...over, + }; +} + +describe('heroLookKey', () => { + it('is stable across session ids', () => { + const a = agent({ id: 'aaa' }); + const b = agent({ id: 'bbb' }); + expect(heroLookKey(a)).toBe(heroLookKey(b)); + }); + + it('differs by source, cwd, or name', () => { + const base = heroLookKey(agent()); + expect(heroLookKey(agent({ source: 'codex' }))).not.toBe(base); + expect(heroLookKey(agent({ cwd: '/other' }))).not.toBe(base); + expect(heroLookKey(agent({ name: 'other' }))).not.toBe(base); + }); +}); + +describe('applyHeroLook', () => { + it('returns the same object when no look is stored', () => { + const a = agent(); + expect(applyHeroLook(a, undefined)).toBe(a); + }); + + it('returns the same object when the look already matches', () => { + const a = agent({ heroClass: 'archer', heroColor: 'red' }); + expect(applyHeroLook(a, { heroClass: 'archer', heroColor: 'red' })).toBe(a); + }); + + it('overrides class and color', () => { + const a = agent(); + const next = applyHeroLook(a, { heroClass: 'pawn', heroColor: 'purple' }); + expect(next.heroClass).toBe('pawn'); + expect(next.heroColor).toBe('purple'); + expect(next.id).toBe(a.id); + expect(a.heroClass).toBe('warrior'); + }); +}); + +describe('parseHeroLooks / serializeHeroLooks', () => { + it('round-trips a valid map', () => { + const looks = { 'cursor\t/p\tN': { heroClass: 'archer' as const, heroColor: 'yellow' as const } }; + expect(parseHeroLooks(serializeHeroLooks(looks))).toEqual(looks); + }); + + it('returns {} for null, garbage, or non-object', () => { + expect(parseHeroLooks(null)).toEqual({}); + expect(parseHeroLooks('not json')).toEqual({}); + expect(parseHeroLooks('"x"')).toEqual({}); + expect(parseHeroLooks('[]')).toEqual({}); + }); + + it('drops invalid keys and looks', () => { + const raw = JSON.stringify({ + v: 1, + looks: { + ok: { heroClass: 'warrior', heroColor: 'blue' }, + '': { heroClass: 'warrior', heroColor: 'blue' }, + badClass: { heroClass: 'mage', heroColor: 'blue' }, + badColor: { heroClass: 'warrior', heroColor: 'pink' }, + notObj: 3, + }, + }); + expect(parseHeroLooks(raw)).toEqual({ + ok: { heroClass: 'warrior', heroColor: 'blue' }, + }); + }); +}); diff --git a/client/src/heroLooks.ts b/client/src/heroLooks.ts new file mode 100644 index 0000000..f08e053 --- /dev/null +++ b/client/src/heroLooks.ts @@ -0,0 +1,61 @@ +import { + HERO_CLASSES, + HERO_COLORS, + type AgentState, + type HeroClass, + type HeroColor, +} from './types/agent'; + +export interface HeroLook { + heroClass: HeroClass; + heroColor: HeroColor; +} + +export type HeroLookMap = Record; + +/** Stable identity: same source + cwd + display name keeps the same look + * across sessions. Session ids change every chat. */ +export function heroLookKey(agent: Pick): string { + return `${agent.source}\t${agent.cwd}\t${agent.name}`; +} + +export function isHeroClass(v: unknown): v is HeroClass { + return typeof v === 'string' && (HERO_CLASSES as readonly string[]).includes(v); +} + +export function isHeroColor(v: unknown): v is HeroColor { + return typeof v === 'string' && (HERO_COLORS as readonly string[]).includes(v); +} + +export function applyHeroLook(agent: AgentState, look: HeroLook | undefined): AgentState { + if (look === undefined) return agent; + if (agent.heroClass === look.heroClass && agent.heroColor === look.heroColor) return agent; + return { ...agent, heroClass: look.heroClass, heroColor: look.heroColor }; +} + +export function parseHeroLooks(raw: string | null): HeroLookMap { + if (raw === null) return {}; + let obj: unknown; + try { + obj = JSON.parse(raw); + } catch { + return {}; + } + if (obj === null || typeof obj !== 'object') return {}; + const looksRaw = (obj as Record).looks; + if (looksRaw === null || typeof looksRaw !== 'object' || Array.isArray(looksRaw)) return {}; + + const out: HeroLookMap = {}; + for (const [key, value] of Object.entries(looksRaw as Record)) { + if (key.length === 0) continue; + if (value === null || typeof value !== 'object') continue; + const rec = value as Record; + if (!isHeroClass(rec.heroClass) || !isHeroColor(rec.heroColor)) continue; + out[key] = { heroClass: rec.heroClass, heroColor: rec.heroColor }; + } + return out; +} + +export function serializeHeroLooks(looks: HeroLookMap): string { + return JSON.stringify({ v: 1, looks }); +} diff --git a/client/src/hooks/useHeroLooks.ts b/client/src/hooks/useHeroLooks.ts new file mode 100644 index 0000000..bbca5d5 --- /dev/null +++ b/client/src/hooks/useHeroLooks.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + parseHeroLooks, + serializeHeroLooks, + type HeroLook, + type HeroLookMap, +} from '../heroLooks'; + +const STORAGE_KEY = 'agentquest:heroLooks'; +const WRITE_DEBOUNCE_MS = 200; + +export function useHeroLooks(): [HeroLookMap, (key: string, look: HeroLook | null) => void] { + const [looks, setLooks] = useState(() => { + if (typeof window === 'undefined') return {}; + return parseHeroLooks(window.localStorage.getItem(STORAGE_KEY)); + }); + + const writeTimer = useRef | null>(null); + const pendingValue = useRef(null); + + useEffect(() => { + if (writeTimer.current !== null) clearTimeout(writeTimer.current); + pendingValue.current = looks; + writeTimer.current = setTimeout(() => { + try { + window.localStorage.setItem(STORAGE_KEY, serializeHeroLooks(looks)); + } catch { /* quota or private mode — silently ignore */ } + pendingValue.current = null; + }, WRITE_DEBOUNCE_MS); + return () => { + if (writeTimer.current !== null) clearTimeout(writeTimer.current); + }; + }, [looks]); + + useEffect(() => { + return () => { + if (pendingValue.current !== null) { + try { + window.localStorage.setItem(STORAGE_KEY, serializeHeroLooks(pendingValue.current)); + } catch { /* quota or private mode — silently ignore */ } + pendingValue.current = null; + } + }; + }, []); + + const upsert = useCallback((key: string, look: HeroLook | null) => { + setLooks((prev) => { + if (look === null) { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + } + const existing = prev[key]; + if (existing !== undefined + && existing.heroClass === look.heroClass + && existing.heroColor === look.heroColor) { + return prev; + } + return { ...prev, [key]: look }; + }); + }, []); + + return [looks, upsert]; +}