182 lines
6.1 KiB
TypeScript
182 lines
6.1 KiB
TypeScript
|
|
// ============================================================================
|
||
|
|
// 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
|
||
|
|
}
|
||
|
|
}
|