// ============================================================================ // 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(); /** Which side of the office: dev or business */ readonly side = input('dev'); /** Desk index on the assigned side (0-based) */ readonly deskIndex = input(0); /** Override state (for standalone demo use, otherwise read from service) */ readonly state = input('idle'); /** Override progress (for standalone demo use) */ readonly progress = input(0); /** Override display name (for standalone demo use) */ readonly displayName = input(''); /** Override task description (for standalone demo use) */ readonly currentTask = input(''); /** Emits when the walking animation completes */ readonly walkingComplete = output(); /** Emits when the returning animation completes */ readonly returningComplete = output(); 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('none'); /** Computed: current state (from service if available, otherwise input) */ readonly currentState = computed(() => { 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 = { 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 } }