Skip to content

Commit bce9f2b

Browse files
author
Ubuntu
committed
feat(web): #79 agent speech bubbles
- Add SpeechBubble class (dark bg, tail, float animation, auto-hide with fade) - showSpeech() method on AgentSprite - showSpeech() + showSpeech() on PixiApp - Speech bubble triggered on status change (highlightAgent shows agent role) - agentMap in PixiApp to store current agent data
1 parent 38adcf1 commit bce9f2b

3 files changed

Lines changed: 120 additions & 1 deletion

File tree

packages/web/src/pixel/AgentSprite.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
import type { Agent } from '../types';
66
import type { PixelState } from './types';
77
import { STATUS_TO_PIXEL } from './types';
8-
import { StatusBubble } from './SceneDecorations';
8+
import { StatusBubble, SpeechBubble } from './SceneDecorations';
99

1010
// ──────────────────────────────────────────────
1111
// Sprite sheet registry
@@ -103,6 +103,7 @@ export class AgentSprite {
103103
private label: Text;
104104
private errorOverlay: Graphics | null = null;
105105
private bubble: StatusBubble | null = null;
106+
private speechBubble: SpeechBubble | null = null;
106107
private currentState: PixelState;
107108
private agentKey: string;
108109
private tint: number | null;
@@ -123,6 +124,16 @@ export class AgentSprite {
123124
return { x: this.container.x + 32, y: this.container.y + 32 };
124125
}
125126

127+
/** Show a speech bubble with the given text */
128+
showSpeech(text: string, durationMs = 4000) {
129+
if (!this.speechBubble) {
130+
this.speechBubble = new SpeechBubble();
131+
this.speechBubble.container.position.set(0, -50);
132+
this.container.addChild(this.speechBubble.container);
133+
}
134+
this.speechBubble.show(text, durationMs);
135+
}
136+
126137
constructor(agent: Agent, onClickCallback?: (agent: Agent, screenX: number, screenY: number) => void) {
127138
this.agent = agent;
128139
this.currentState = STATUS_TO_PIXEL[agent.status];
@@ -301,6 +312,11 @@ export class AgentSprite {
301312
if (this.bubble) {
302313
this.bubble.tick(frame / 60);
303314
}
315+
316+
// Speech bubble tick
317+
if (this.speechBubble) {
318+
this.speechBubble.tick(frame / 60);
319+
}
304320
}
305321

306322
/** Flash highlight effect for event feedback */
@@ -368,6 +384,7 @@ export class AgentSprite {
368384
destroy() {
369385
this.destroyed = true;
370386
this.bubble?.destroy();
387+
this.speechBubble?.destroy();
371388
this.container.destroy({ children: true });
372389
}
373390
}

packages/web/src/pixel/PixiApp.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class PixiApp {
1616
private app: Application | null = null;
1717
private world: Container | null = null; // pan/zoom container wrapping everything
1818
private sprites: Map<string, AgentSprite> = new Map();
19+
private agentMap: Map<string, Agent> = new Map();
1920
private decorations: SceneDecorations | null = null;
2021
private initialized = false;
2122
private frame = 0;
@@ -203,6 +204,18 @@ export class PixiApp {
203204
sprite.flash(durationMs);
204205
const pos = sprite.getPosition();
205206
this.particles.spawn(pos.x, pos.y, 16, 0xffd700, 80, 0.9, 4);
207+
// Speech bubble feedback
208+
const agent = this.agentMap.get(agentId);
209+
const roleText = agent?.role ?? '工作中';
210+
sprite.showSpeech(roleText, 3500);
211+
}
212+
}
213+
214+
/** Show a speech bubble on an agent */
215+
showSpeech(agentId: string, text: string, durationMs = 4000) {
216+
const sprite = this.sprites.get(agentId);
217+
if (sprite) {
218+
sprite.showSpeech(text, durationMs);
206219
}
207220
}
208221

@@ -304,6 +317,10 @@ export class PixiApp {
304317
const slots = assignFixedSlots(agents.length, statuses);
305318
const monitorSlots: { x: number; y: number; agentId: string; online: boolean }[] = [];
306319

320+
// Update agent map
321+
this.agentMap.clear();
322+
agents.forEach((agent) => this.agentMap.set(agent.id, agent));
323+
307324
agents.forEach((agent, i) => {
308325
const slot = slots[i];
309326
if (!slot) return;

packages/web/src/pixel/SceneDecorations.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,3 +932,88 @@ export class StatusBubble {
932932
this.container.destroy({ children: true });
933933
}
934934
}
935+
936+
// ─── Speech Bubbles (for agent messages/activities) ─────────────────────────────
937+
export class SpeechBubble {
938+
readonly container: Container;
939+
private phase: number = 0;
940+
private autoHideTimer = 0;
941+
private autoHideMs = 0;
942+
private graphics: Graphics;
943+
private label: Text;
944+
945+
constructor() {
946+
this.container = new Container();
947+
this.graphics = new Graphics();
948+
this.label = new Text({
949+
text: '',
950+
style: new TextStyle({
951+
fontSize: 7,
952+
fontFamily: 'monospace',
953+
fill: 0xffffff,
954+
align: 'center',
955+
wordWrap: true,
956+
wordWrapWidth: 80,
957+
}),
958+
});
959+
this.label.anchor.set(0.5, 0.5);
960+
this.container.addChild(this.graphics);
961+
this.container.addChild(this.label);
962+
this.container.visible = false;
963+
this.container.zIndex = 100;
964+
}
965+
966+
show(text: string, durationMs = 4000) {
967+
this.label.text = text;
968+
// Draw bubble background sized to text
969+
const padding = 4;
970+
const textBounds = this.label.getBounds();
971+
const bw = Math.max(40, textBounds.width + padding * 2);
972+
const bh = Math.max(20, textBounds.height + padding * 2);
973+
this.graphics.clear();
974+
// Pixel-art style rounded rect with dark bg
975+
this.graphics.roundRect(-bw / 2, -bh / 2, bw, bh, 4).fill({ color: 0x1a1a2e, alpha: 0.95 });
976+
this.graphics.roundRect(-bw / 2, -bh / 2, bw, bh, 4).stroke({ color: 0x4a4a6a, width: 1, alpha: 0.8 });
977+
// Small tail pointing down
978+
this.graphics.moveTo(-4, bh / 2 - 1);
979+
this.graphics.lineTo(0, bh / 2 + 5);
980+
this.graphics.lineTo(4, bh / 2 - 1);
981+
this.graphics.closePath().fill({ color: 0x1a1a2e, alpha: 0.95 });
982+
983+
this.label.position.set(0, 0);
984+
this.container.visible = true;
985+
this.container.alpha = 1;
986+
this.autoHideMs = durationMs;
987+
this.autoHideTimer = 0;
988+
this.phase = Math.random() * Math.PI * 2;
989+
}
990+
991+
hide() {
992+
this.container.visible = false;
993+
this.label.text = '';
994+
}
995+
996+
tick(elapsed: number) {
997+
if (!this.container.visible) return;
998+
999+
// Auto-hide
1000+
this.autoHideTimer += 1 / 60 * 1000;
1001+
if (this.autoHideTimer >= this.autoHideMs) {
1002+
this.hide();
1003+
return;
1004+
}
1005+
1006+
// Fade out in last 20% of life
1007+
const fadeStart = this.autoHideMs * 0.8;
1008+
if (this.autoHideTimer > fadeStart) {
1009+
this.container.alpha = 1 - (this.autoHideTimer - fadeStart) / (this.autoHideMs - fadeStart);
1010+
}
1011+
1012+
// Float animation
1013+
this.container.y = Math.sin(elapsed * 2.8 + this.phase) * 3;
1014+
}
1015+
1016+
destroy() {
1017+
this.container.destroy({ children: true });
1018+
}
1019+
}

0 commit comments

Comments
 (0)