Files
Control-Center/frontend/src/app/components/minion/minion.component.ts

182 lines
6.1 KiB
TypeScript
Raw Normal View History

// ============================================================================
// Minion Component — 16-bit Breakroom Minion with State Animations
// Per CUB-60: Minion State & Animation System
// ============================================================================
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
input,
output,
signal,
computed,
effect,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MinionState, MinionSide } from '../../models/minion.model';
import { MinionStateService } from '../../services/minion-state.service';
/**
* MinionComponent renders a single 16-bit minion character with four visual states:
* - idle: In breakroom eating bananas, watching TV
* - walking: Moving from breakroom to desk (2s animation, then auto working)
* - working: At desk typing on laptop, progress bar overhead
* - returning: Walking back to breakroom (2s animation, then auto idle)
*
* The component reads its state from MinionStateService and emits events
* when animations complete so the service can advance the state machine.
*/
@Component({
selector: 'app-minion',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatProgressBarModule,
MatTooltipModule,
],
templateUrl: './minion.component.html',
styleUrl: './minion.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MinionComponent implements OnInit, OnDestroy {
/** Agent name — used to look up state from MinionStateService */
readonly agentName = input.required<string>();
/** Which side of the office: dev or business */
readonly side = input<MinionSide>('dev');
/** Desk index on the assigned side (0-based) */
readonly deskIndex = input<number>(0);
/** Override state (for standalone demo use, otherwise read from service) */
readonly state = input<MinionState>('idle');
/** Override progress (for standalone demo use) */
readonly progress = input<number>(0);
/** Override display name (for standalone demo use) */
readonly displayName = input<string>('');
/** Override task description (for standalone demo use) */
readonly currentTask = input<string>('');
/** Emits when the walking animation completes */
readonly walkingComplete = output<string>();
/** Emits when the returning animation completes */
readonly returningComplete = output<string>();
private readonly minionStateService = inject(MinionStateService);
/** Track whether to use service data or input data */
private readonly useServiceData = signal(false);
/** Animation state — tracks which animation is active */
readonly animationState = signal<MinionState | 'none'>('none');
/** Computed: current state (from service if available, otherwise input) */
readonly currentState = computed<MinionState>(() => {
if (this.useServiceData()) {
const minion = this.minionStateService.getMinion(this.agentName());
return minion?.state ?? this.state();
}
return this.state();
});
/** Computed: current progress */
readonly currentProgress = computed(() => {
if (this.useServiceData()) {
const minion = this.minionStateService.getMinion(this.agentName());
return minion?.progress ?? this.progress();
}
return this.progress();
});
/** Computed: display name */
readonly displayLabel = computed(() => {
if (this.useServiceData()) {
const minion = this.minionStateService.getMinion(this.agentName());
return minion?.displayName ?? this.displayName() ?? this.agentName();
}
return this.displayName() || this.agentName();
});
/** Computed: current task */
readonly currentTaskLabel = computed(() => {
if (this.useServiceData()) {
const minion = this.minionStateService.getMinion(this.agentName());
return minion?.currentTask ?? this.currentTask() ?? '';
}
return this.currentTask();
});
/** Computed: CSS class based on current state */
readonly stateClass = computed(() => `minion--${this.currentState()}`);
/** Computed: whether to show the progress bar */
readonly showProgress = computed(() => this.currentState() === 'working' && this.currentProgress() > 0);
/** Computed: side class */
readonly sideClass = computed(() => `minion--${this.side()}`);
/** Computed: position style for desk assignment */
readonly deskPosition = computed(() => this.deskIndex());
/** Computed: human-readable state label */
readonly stateLabel = computed(() => {
const labels: Record<MinionState, string> = {
idle: 'On Break',
walking: 'Heading to Desk',
working: 'Working',
returning: 'Returning to Breakroom',
};
return labels[this.currentState()];
});
ngOnInit(): void {
// If the service has data for this agent, use it
const minion = this.minionStateService.getMinion(this.agentName());
if (minion) {
this.useServiceData.set(true);
}
// Sync animation state with current state
this.animationState.set(this.currentState());
}
/**
* Handle animation end events from CSS animations.
* Only fires for walking/returning which have finite durations.
*/
onAnimationEnd(event: AnimationEvent): void {
const state = this.currentState();
if (state === 'walking' && event.animationName === 'minionWalkRight') {
this.walkingComplete.emit(this.agentName());
// Auto-transition walking → working via service
if (this.useServiceData()) {
this.minionStateService.onWalkingComplete(this.agentName());
}
this.animationState.set('working');
}
if (state === 'returning' && event.animationName === 'minionWalkLeft') {
this.returningComplete.emit(this.agentName());
// Auto-transition returning → idle via service
if (this.useServiceData()) {
this.minionStateService.onReturningComplete(this.agentName());
}
this.animationState.set('idle');
}
}
ngOnDestroy(): void {
// Cleanup handled by service if needed
}
}